《Day 05:把暫停機制與啟動方式放在一起跑一次》結尾留下一個問題沒有回答:協程數量一多,誰該負責統一管理它們,確保這些協程不會各自失控地一直跑下去。今天要正式回答這個問題。
三個協程還能靠人腦逐一推演,幾十個協程就不行了。靠肉眼一個個檢查誰活著、誰已經結束,這種做法從一開始就撐不久,勢必需要某種機制,把彼此相關的協程收在同一個地方管理。
答案是 Coroutine Scope。在 Kotlin 協程的設計裡,每一個協程都必須在某個 Coroutine Scope 裡啟動,不存在憑空遊蕩、沒有歸屬的協程。這個詞從今天起正式定案,全系列後續一律保留英文原文,不另外翻譯、不創中文譯名。
回頭看看前幾天寫過的程式碼,會發現一件有趣的事,Coroutine Scope 其實早就在場,只是一直沒被正式點名過。
Day 05:把暫停機制與啟動方式放在一起跑一次 那段整合範例裡,launch 與 async 都不是憑空被呼叫的,它們是在 runBlocking 大括號裡面被呼叫的。這個大括號圈起來的範圍,正是一個 Coroutine Scope。runBlocking 除了負責把執行緒帶進協程世界,同時也順手建立並提供了這樣一個環境,讓裡面的 launch 跟 async 有地方可以掛。先前只講了 runBlocking 會阻塞執行緒這件事,卻沒提到它同時也扮演了 Scope 的角色,今天正式把這塊補上。
這不只是一個順便附帶的細節,而是 Kotlin 協程刻意做出的硬性限制。呼叫 launch 或 async 這類 Coroutine Builder 時,如果找不到一個 Scope 可以歸屬,程式碼根本無法通過編譯。試著在一個普通函式裡,沒有經過 runBlocking 或任何 Scope 就直接寫下 launch { ... },編譯器會直接擋下來,因為協程沒有一個天然可以存在的地方。這是設計上的刻意約束,不是語言團隊的疏忽或遺漏。
Coroutine Scope 本身也有自己的生命週期,這件事帶出協程和傳統 Thread 一個很根本的行為差異。
傳統 Thread 一旦啟動,通常沒有一個天然機制會在某個父層結束時,自動要求它跟著停下來。你得自己想辦法追蹤這條 Thread,自己寫程式碼去中斷它,一旦漏了這道手續,它就會在背景默默跑下去,直到自己執行完畢為止。協程不是這樣運作的。當一個 Coroutine Scope 被取消或結束時,這個 Scope 裡啟動的所有協程,理論上也會被要求跟著結束,這種「跟著父層一起收尾」的行為,是內建在協程設計裡的。
拿多來源圖片下載與聚合工具具體想像一次。假設一次聚合下載任務對應一個 Coroutine Scope,這個 Scope 底下可能同時掛著十幾個下載協程,各自負責一個網址。如果使用者提早按下取消,或整個工具需要提前結束,這個 Scope 底下所有還在進行中的下載,理論上都該跟著被收拾掉,而不是繼續在背景默默跑完,白白花時間去下載一張早就沒人需要的圖片。
這裡先只停在「Scope 結束、協程跟著結束」這個方向性的認識就好。取消訊號實際上怎麼傳到協程內部,協程又是怎麼回應這個訊號,這些細節留到後面談協程取消機制的時候再展開,今天不碰。
前面一直在講 Scope,但還沒看過它長什麼樣子。建立一個最小的 Coroutine Scope,語法其實相當輕量。
val downloadScope = CoroutineScope(Job())
或者更常見的,直接在需要的地方建立並使用:
fun startAggregateDownload(urls: List<String>) {
val downloadScope = CoroutineScope(Job())
urls.forEach { url ->
downloadScope.launch {
val bytes = downloadImage(url)
println("$url 下載完成,大小為 ${bytes.size} bytes")
}
}
}
Job() 這裡先不深入解釋它的完整定義,今天只需要知道,建立一個 Coroutine Scope 需要搭配這樣一個目前先不解釋的必要參數,之後系列會回頭正式說明它扮演的角色。延續前面的下載任務場景,urls.forEach 裡的每一次 downloadScope.launch,都是在同一個 Scope 裡啟動一個下載協程,它們共用同一個生命週期邊界。範例只示範到建立 Scope 與在其中啟動協程這個層級,至於這個 Scope 該在什麼時機被正式結束或取消,完整規則留到後面章節。
建立 Scope 這件事,語法本身談不上複雜,真正需要小心的地方在別處。不同的建立方式,會帶來完全不同的生命週期行為,例如一個從程式啟動就存在、幾乎不會結束的全域 Scope,跟一個綁定某個任務範圍、任務結束就該跟著結束的 Scope,兩者的責任完全不同。這個選擇本身就是一個重要的設計決策,會直接影響到協程管理起來是輕鬆還是麻煩。今天先建立這個概念,具體該怎麼選,留給後續章節依情境逐步展開。
今天走到這裡,Scope 本身是什麼、它管的是誰,已經有了清楚的畫面。但還有一個問題被刻意留白。
同一個 Scope 底下,往往不會只養一個協程。如果其中一個先執行完了,或者其中一個中途發生了錯誤,這件事會不會影響到同一個 Scope 裡的其他協程?它們之間彼此到底是什麼關係,各自獨立互不相關,還是存在某種說不清楚的牽連?
這個問題今天先不回答。答案會在 《Day 07:Structured Concurrency,為什麼協程不能亂長亂放》 正式定案,那篇會揭開 Structured Concurrency,結構化並發,這個系列最重要的核心觀念之一。